iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
Vue.js

作為 Angular 專家探索 Vue 3 和 Svelte 5系列 第 18

第 17 天 - 在 HTML 模板中渲染動態內容

  • 分享至 

  • xImage
  •  

第 17 天 - 在 HTML 模板中渲染動態內容

在第 17 天,我示範如何在元件中渲染動態內容。Vue 3 將內容投影到 slots,並可選擇性地顯示插槽屬性 (slot props)。在 Svelte 5 中,slot 被 snippet 取代,而 render 標籤 (tag) 則是在模板中渲染片段 (fragment)。Angular 提供 ng-content 用於內容投影,並用 ng-template 建立可在 ng-container 中顯示的模板片段 (template fragment)。

在這篇部落格文章中,包含兩個內容投影 (content projection) 的範例。第一個範例會在滑鼠懸停時更新 Add Plan 按鈕的文字。第二個範例在 CoffeePlan 元件中渲染條件插槽,當選擇咖啡方案時,該方案會顯示所投影的圖標。

插入插槽以投影新增方案按鈕文字

  • Vue 3 application
const hover = ref(false);
<button type="submit" 
    @mouseenter="hover = true" 
    @mouseleave="hover = false">
    <slot name="btn" :hover="hover" />
</button>

AddCoffeePlan 元件中,於按鈕元素的子元素位置插入具名插槽。hover 是一個插槽屬性,會將 hover 參考的值傳回給 PlanPicker 元件。當發生 mouseenter 事件時,hover 參考為 true;當發生 mouseleave 事件時,則為 false

PlanPicker 元件可以使用此插槽屬性 (slot prop) 值來投影按鈕文字。

  • SvelteKit application
<script>
    import type { Snippet } from 'svelte';

    interface Props {
        addPlanButton: Snippet<[boolean]>;
    }

    const { addPlanButton }: Props = $props();

    let hover = $state(false);
</script>
<button type="submit"
    onmouseenter={() => (hover = true)}
    onmouseleave={() => (hover = false)}
>
    {@render addPlanButton(hover)}
</button>

CoffeePlan 元件匯入 Snippet 類型以為 addPlanButton 屬性添加型別。addPlanButton 是一個接受 boolean 參數的片段 (fragment)。此外,hover 是一個 rune,在 mouseenter 事件時設為 true,mouseleave 事件時設為 false

@render 標籤 (tag) 將該 rune 傳遞給 addPlanButton 片段並渲染它。

  • Angular 19 application
@Component({
  ...
})
export class CoffeePlanComponent {
  hover = output<boolean>();
}
<button type="submit" 
    (mouseenter)="hover.emit(true)"
    (mouseleave)="hover.emit(false)"
>
    <ng-content />
</button>

我找不到 Angular 中與插槽屬性 (slot prop) 相當的概念。因此,我定義了一個自訂的 hover 事件,將其值發送給 PlanPicker 元件。

我也在按鈕元素中插入了 <ng-content>,使 PlanPicker 元件能夠投影 Add Plan 按鈕的文字。

投影 Add Plan 按鈕文字

  • Vue 3 application
<AddCoffeePlan>
    <template #btn="{ hover }">
        Add Plan  {{ hover ? '(+1)' : '' }}
    </template>
</AddCoffeePlan>

<AddCoffeePlan> 元件的主體中有一個名為 #btn 的模板。從插槽 (slot) 屬性解構出 hover 屬性。當 hover 為 true 時,按鈕文字為 Add Plan (+1),否則文字為 Add Plan

  • SvelteKit application
<AddCoffeePlan>
    {#snippet addPlanButton(hover: boolean)}
        Add Plan {hover ? '(+1)' : ''}
    {/snippet}	
</AddCoffeePlan>

addPlanButton 片段 (snippet) 宣告在 AddCoffeePlan 元件內,因此它是該元件的隱含屬性 (implicit prop)。

同樣地,當 hover 參數為 true 時,按鈕文字為 Add Plan (+1);否則文字為 Add Plan

  • Angular 19 application
<app-add-coffee-plan (hover)="hover.set($event)">
   {{ addPlanText() }}
</app-add-coffee-plan>
export class PlanPickerComponent {
  hover = signal(false);

  addPlanText = computed(() => `Add Plan ${this.hover() ? '(+1)' : ''}`);
}

hover 自訂事件會發出一個 boolean 值,直接設定 hover 信號。

接著,addPlanText 計算屬性根據 hover 信號的值決定按鈕文字。

<app-add-coffee-plan> 元件的主體中,呼叫 addPlanText getter,並將文字投影到新增方案按鈕上。

投影條件內容 (Conditional Slots)

當選擇 coffee plan 且方案名稱以 'The' 開頭時,會投影 coffeecoffee maker 圖標。當選擇的方案名稱不以 'The' 開頭時,會投影 teaburger 圖標。未選擇的方案則不顯示任何圖標。

  • 安裝圖標函式庫

    • Vue 3
      npm install --save-dev @iconify/vue
      
    • Svelte 5
      npm install --save-dev @iconify/svelte
      
    • Angular
      npm install @ng-icons/core @ng-icons/material-icons
      
  • 建立模板以渲染不同圖標

  • Vue 3 Application

import { Icon } from '@iconify/vue';
<CoffeePlan v-for="plan in plans">
  <template #coffee v-if="isSelected(plan) && plan.startsWith('The')">
    <div class="coffee">
      <Icon class="icon" v-for="icon in ['ic:outline-coffee', 'ic:outline-coffee-maker']" 
        :key="icon" :icon="icon" />
    </div>
  </template>

  <template #beverage v-if="isSelected(plan) && !plan.startsWith('The')">
    <div class="beverage">
      <Icon class="icon" v-for="icon in ['ic:outline-emoji-food-beverage', 'ic:outline-fastfood']" :key="icon" :icon="icon" />
    </div>        
  </template>
</CoffeePlan>

PlanPicker 元件中建立了兩個具名模板。當所選方案以 'The' 開頭時,會渲染 coffee 模板。該模板顯示 coffeecoffee maker圖標。不以 'The' 開頭的方案則顯示 beverage 模板。beverage 模板渲染 teaburger 圖標。

coffee 模板中的圖標尺寸設為 48px。beverage 模板中的圖標較小且為藍色,尺寸為 42px。

  • SvelteKit Application
import Icon from "@iconify/svelte";
{#snippet selectedPlanIcons()}
    <div class="coffee">
        {#each ['ic:outline-coffee', 'ic:outline-coffee-maker'] as name (name)}
            <Icon icon={name} width="48" height="48" />
        {/each}	
    </div>
{/snippet}

{#snippet selectedPlanBeverageIcons()}
    <div class="beverage">
        {#each ['ic:outline-emoji-food-beverage', 'ic:outline-fastfood'] as name (name)}
            <Icon icon={name} width="42" height="42" color="blue" />
        {/each}
    </div>
{/snippet}

同樣地,selectedPlanIcons 片段渲染 coffeecoffee maker 圖標,而 selectedPlanBeverageIcons 片段則渲染 teaburger 圖標。

{#each plans as plan (plan)}
    {#if isSelected(plan)}
        <CoffeePlan 
        selectedPlanBeverageIcons={!plan.startsWith('The') ? selectedPlanBeverageIcons : undefined}
        selectedPlanIcons={plan.startsWith('The') ? selectedPlanIcons : undefined} />
    {:else}
        <CoffeePlan name={plan} {selectedPlan} selected={isSelected(plan)} />
    {/if}
{/each}

這些片段作為屬性 (props) 傳遞給 CoffeePlan 元件。當所選方案名稱不以 'The' 開頭時,selectedPlanBeverageIcons 屬性會接收 selectedPlanBeverageIcons 模板,否則該屬性為未定義 (undefined)。當名稱以 'The' 開頭時,selectedPlanIcons 屬性會接收 selectedPlanIcons 模板,否則該屬性為未定義。

  • Angular Application

Angular 的 NgTemplateOutlet 更適合此使用情境。PlanPicker 元件建立了兩個模板片段,coffeebeverage,分別渲染不同的圖標。這些片段作為信號輸入傳遞給 CoffeePlan 元件。

import { NgIcon, provideIcons } from '@ng-icons/core';
import {
  matCoffeeMakerOutline,
  matCoffeeOutline,
  matEmojiFoodBeverageOutline,
  matFastfoodOutline,
} from '@ng-icons/material-icons/outline';

@Component({
  selector: 'app-plan-picker',
  imports: [NgIcon],
  viewProviders: [
    provideIcons({ matCoffeeMakerOutline, matCoffeeOutline, matEmojiFoodBeverageOutline, matFastfoodOutline }),
  ],
}) 
export class PlanPickerComponent {}

PlanPicker 元件從 ng-iconsng-icons/material-icons 函式庫匯入類別和 SVG 圖標。

  <ng-template #coffee>
    <div class="coffee">
      @for (iconName of ['matCoffeeOutline', 'matCoffeeMakerOutline']; track iconName) {
        <ng-icon class="icon" [name]="iconName" />
      }
    </div>
  </ng-template>
.coffee {
  flex: display;
  align-items: center;

  > .icon {
    width: 48px;
    height: 48px;
    color: brown;
  }
}

此模板片段有一個模板變數 coffee。它渲染 coffeecoffee maker 圖標,並使用簡單的 CSS 類別調整其尺寸和顏色。

  <ng-template #beverage>
    <div class="beverage">
      @for (iconName of ['matEmojiFoodBeverageOutline', 'matFastfoodOutline']; track iconName) {
        <ng-icon class="icon" [name]="iconName" />
      }
    </div>
  </ng-template>
.beverage {
  display: flex; 
  flex-direction: column; 
  padding: 0.25rem;

  > .icon {
    width: 42px;
    height: 42px;
    color: green;
  }
}

beverage 模板片段會渲染 teaburger 圖標,並使用 CSS 類別使其呈現藍色且大小為 42px。

@for (plan of plans(); track plan) {
    @let coffeeTemplate = isSelected && plan.startsWith('The') ? coffee : undefined;
    @let beverageTemplate = isSelected && !plan.startsWith('The') ? beverage : undefined;
    <app-coffee-plan
      [coffeeTemplate]="coffeeTemplate"
      [beverageTemplate]="beverageTemplate"
    />
}

當所選方案名稱以 'The' 開頭時, coffeeTemplate 輸入會接收 coffee 模板片段。當所選方案名稱不符合 'The' 的條件時, beverageTemplate 輸入會接收 beverage 模板片段。

更新 CoffeePlan 元件以渲染片段 (Fragments)

  • Vue 3 Application
<template v-if="$slots.coffee">
     <slot name="coffee" />
</template>

<div class="description">
     <span class="title"> {{ name }} </span>
</div>

<template v-if="$slots.beverage">
    <slot name="beverage" />
</template>

$slots.coffeetrue 時,PlanPicker 元件會將 coffee 模板投影到 coffee 插槽。圖標顯示在描述的左側。

$slots.beveragetrue 時,PlanPicker 元件會將 beverage 模板投影到 beverage 插槽。圖標顯示在描述的右側。

  • Svelte 5 Application
interface Props {
    selectedPlanIcons?: Snippet;
    selectedPlanBeverageIcons?: Snippet;
}

let {
    name = 'Default Plan',
    selectedPlan,
    selected,
    selectedPlanIcons,
    selectedPlanBeverageIcons
}: Props = $props();

CoffeePlan 元件中,於 Props 介面新增可自選的 selectedPlanIconsselectedPlanBeverageIcons 片段。

接著,selectedPlanIconsselectedPlanBeverageIcons 從元件的屬性中解構出來。

<div>
	{@render selectedPlanIcons?.()}
	<div class="description">
		<span class="title"> {name} </span>
	</div>
	{@render selectedPlanBeverageIcons?.()}
</div>

render 標籤 (tag) 會在描述的左側渲染可自選的 selectedPlanIcons 片段。可自選的 selectedPlanBeverageIcons 片段則渲染在描述的右側。

  • Angular Application
@Component({
  selector: 'app-coffee-plan',
  imports: [NgTemplateOutlet],  
})
export class CoffeePlanComponent {
    coffeeTemplate = input<TemplateRef<any> | undefined>(undefined);
    beverageTemplate = input<TemplateRef<any> | undefined>(undefined);
}

CoffeePlan 元件匯入了 NgTemplateOutlet 以使用該指令 (directive)。coffeeTemplatebeverageTemplate 是可自選的模板參考,指向 PlanPicker 元件中的片段。

<ng-container [ngTemplateOutlet]="coffeeTemplate()" />

<div class="description">
    <span class="title"> {{ name() }} </span>
</div>

<ng-container [ngTemplateOutlet]="beverageTemplate()" />

ngTemplateOutletNgTemplateOutlet 指令的一個輸入。它接受一個模板參考並顯示 ng-template 的內容。

第一個 ng-container 嵌入了 coffee 模板,第二個 ng-container 嵌入了 beverage 模板。咖啡圖標渲染在描述的左側,飲料圖標渲染在右側。

ngTemplateOutlet 輸入為未定義 (undefined) 時,將不會渲染任何內容。

結論

我們已成功在 CoffeePlan 元件中進行內容投影 (content projection) 並渲染條件插槽 (conditional slots)。Vue 3 使用插槽來顯示可重複使用的模板。Svelte 5 引入 snippet 和 render 來達成相同的目的。Angular 提供 ngContent 用於投影,並使用 ngTemplate 創建模板片段,將動態內容嵌入 ngContainer

資源

Github Repositories:


上一篇
第16天 - 使用元件事件選擇咖啡方案
下一篇
第18天 - Github Card 專案 第一部分 - 資料擷取
系列文
作為 Angular 專家探索 Vue 3 和 Svelte 519
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言